process.cwd와 __dirname의 차이를 실행 위치로 이해하기

process.cwd와 __dirname의 차이를 실행 위치로 이해하기

로컬에서는 잘 읽히던 설정 파일이 배포 서버나 테스트에서만 사라지는 경우가 있다. 상대 경로의 기준을 현재 작업 디렉터리로 생각했는지, 모듈 파일의 위치로 생각했는지 섞였을 가능성이 크다.

한눈에 보기

  • process.cwd()는 프로세스를 시작한 위치다.
  • __dirname은 현재 CommonJS 파일이 들어 있는 위치다.
  • 사용자 입력 경로는 cwd, 코드와 함께 배포된 자산은 모듈 위치를 기준으로 삼는 경우가 많다.

목차

두 경로의 기준점

// /app에서 node src/server.js를 실행했다고 가정한다.
console.log(process.cwd()); // /app
console.log(__dirname);     // /app/src

같은 파일이라도 다른 디렉터리에서 명령을 실행하면 cwd는 달라진다. __dirname은 파일 위치를 따르므로 변하지 않는다.

어떤 기준을 선택할까

CLI 사용자가 넘긴 상대 경로, 프로젝트 루트의 환경 파일처럼 실행 위치와 관련된 값은 cwd가 자연스럽다. SQL migration, 이메일 템플릿처럼 소스 파일 옆에 배포되는 자산은 모듈 위치가 안전하다.

const path = require("node:path");

const userConfig = path.resolve(process.cwd(), "config.json");
const mailTemplate = path.join(__dirname, "templates", "welcome.html");
기준을 코드에 드러내기

단순한 "./file"보다 path.resolve의 기준점을 명시하면 실행 위치가 바뀌었을 때 문제를 찾기 쉽다.

ESM에서 현재 파일 위치 구하기

ESM에는 __dirname이 기본으로 제공되지 않는다. import.meta.url을 파일 경로로 변환한다.

import path from "node:path";
import { fileURLToPath } from "node:url";

const filename = fileURLToPath(import.meta.url);
const dirname = path.dirname(filename);

테스트에서는 왜 더 자주 깨질까

테스트 러너는 저장소 루트에서 시작될 수도 있고 패키지별 디렉터리에서 시작될 수도 있다. 모노레포라면 같은 테스트도 실행 명령에 따라 cwd가 달라진다.

workspace/
├── package.json
├── apps/
│   └── api/
│       ├── package.json
│       └── src/server.js
└── packages/
    └── mail/templates/welcome.html

다음 두 명령은 같은 server.js를 실행해도 작업 디렉터리가 다르다.

# cwd: workspace
node apps/api/src/server.js

# cwd: workspace/apps/api
cd apps/api
node src/server.js

따라서 path.resolve("config.json")처럼 기준점이 생략된 코드는 각각 다른 파일을 찾는다. 반면 소스와 함께 배포되는 이메일 템플릿은 어느 디렉터리에서 실행하더라도 모듈 기준으로 찾아야 한다.

import path from "node:path";
import { fileURLToPath } from "node:url";

const moduleFile = fileURLToPath(import.meta.url);
const moduleDir = path.dirname(moduleFile);

export const paths = {
  // 운영자가 실행 위치를 기준으로 지정하는 파일
  config: path.resolve(process.cwd(), "config.json"),

  // 코드 패키지와 함께 배포되는 파일
  welcomeTemplate: path.resolve(
    moduleDir,
    "../../mail/templates/welcome.html",
  ),
};
예제 코드

위 디렉터리와 파일명은 경로 기준을 설명하기 위해 만든 예시다. 실제 저장소의 절대 경로나 배포 구조를 사용하지 않았다.

문자열 조합보다 URL을 유지하는 방법

ESM에서는 파일을 읽기 직전까지 URL 객체를 유지하는 방법도 편하다. new URL(relative, import.meta.url)은 운영체제별 경로 구분자를 직접 다루지 않아도 된다.

import { readFile } from "node:fs/promises";

const templateUrl = new URL("./templates/welcome.html", import.meta.url);
const template = await readFile(templateUrl, "utf8");

Node.js의 파일 API 상당수는 file: URL을 직접 받을 수 있다. 꼭 문자열 경로가 필요한 라이브러리 경계에서만 fileURLToPath로 변환하면 된다.

선택 기준을 표로 정리하기

필요한 파일 기준점 이유
CLI 사용자가 넘긴 상대 경로 process.cwd() 사용자가 명령을 실행한 위치가 의미 있음
프로젝트 루트의 설정 명시적인 root 또는 cwd 배포·실행 계약으로 관리
모듈 옆 템플릿 import.meta.url / __dirname 호출 위치와 무관해야 함
업로드 임시 파일 OS 임시 디렉터리 소스 트리에 쓰지 않음
테스트 fixture 테스트 파일의 모듈 위치 러너 실행 위치와 분리

여기서 중요한 것은 cwd를 쓰지 말자는 것이 아니다. 실행한 사람의 위치가 의미일 때만 cwd를 쓴다는 기준이다.

경로 문제를 줄이는 실전 규칙

  1. 애플리케이션 시작 시 주요 경로를 한 번 계산한다.
  2. 하위 함수에는 상대 경로 대신 계산된 절대 경로나 URL을 전달한다.
  3. 라이브러리 코드 안에서 process.chdir()를 호출하지 않는다.
  4. 테스트 하나는 저장소 루트가 아닌 다른 위치에서 프로세스를 실행해 본다.
  5. 오류 메시지에는 어떤 기준으로 어떤 파일을 찾았는지 남긴다. 단, 사용자 홈 경로나 비밀 파일 경로는 외부 응답에 노출하지 않는다.
export async function loadTemplate(templateUrl) {
  try {
    return await readFile(templateUrl, "utf8");
  } catch (error) {
    throw new Error(`Template could not be loaded: ${templateUrl.pathname}`, {
      cause: error,
    });
  }
}

결론

상대 경로 오류를 막는 핵심은 어떤 경로 함수를 쓰느냐보다 기준점을 의도적으로 선택하는 것이다. 테스트와 운영의 실행 디렉터리가 달라도 같은 파일을 찾는지 확인한다.

관련 노트